tags:
- Notes
Cache Pitfalls
这里,我们说的 cache 不是内存架构 (memory architecture) 里面的 CPU cache,而是软件层面的数据缓存,虽然二者不同,但其核心思想相似——将访问数据缓存到高速存储介质中,减少对较慢存储介质(如数据库)的依赖,从而提升系统的整体性能。
服务器中的缓存分多个层面,有 bring it home 的 CDN 缓存,有将浏览器资源缓存到主机内存/磁盘的 HTTP 缓存,还有将磁盘数据库中的数据缓存到内存数据库中的数据库缓存。我们今天要讨论的就是这种数据库缓存这一层面。
我们都知道,磁盘 IO 的访问很慢,即使是 SSD ,速度也仍然只有内存的百分之一,所以内存能够承载的 QPS (Queries Per Second) 要远大于 SSD 甚至 HDD 这类二级存储器。所以如果服务器在服务请求时,所有的数据都存放在磁盘上,那就会造成服务器的高负载(因为需要将数据从磁盘拉到内存中才能相应请求)。
对此,我们的解决方案是将服务响应可能用到的数据缓存到内存中,也就是把常用数据缓存在内存中的一层临时存储层,以便减少访问更慢的存储介质(数据库)的频率,从而提升系统整体的速度和性能,降低服务器负载。
虽然缓存技术确实能够降低服务器的负载,提高服务器的响应速度。但是在设计缓存的时候,我们可能会遇到一系列问题,我们称作——缓存陷阱 (Cache Pitfalls)。其指的是缓存技术的使用过程中可能遇到的常见问题,可能会影响系统性能、数据一致性或稳定性。我们下来一个一个地介绍缓存带来的潜在问题。
缓存带来的首要问题之一是高内存占用。缓存的本质就是将磁盘数据库中的数据存储到内存中以提升访问速度,因此如果不加以限制,缓存数据可能会无限增长,最终导致内存被完全占满。而服务器应用程序运行时需要足够的内存来处理请求,如果缓存占用过多,就会增加系统负载,导致无法分配新进程所需的内存,甚至可能引发系统崩溃 (Out of Memory, OOM)。
为了解决这种问题,我们可以限制缓存最大的内存占用,并使用合理的策略(比如 LRU 或 LFU)来淘汰低频次访问的数据。这样我们就可以避免单机缓存可能带来的 OOM 内存崩溃问题。在常见的内存数据库(如 Redis)中,一般也可以通过设置缓存生存时间 (TTL) 避免过高的内存占用。
这样就会带来另外的问题——由于单机缓存容量有限而造成的缓存命中率过低的问题。如果用户量极大,单个缓存节点就无法存储足够的热数据,那么缓存就会形同虚设。每次访问系统都可能需要完成:缓存未命中->读磁盘->写缓存->响应请求。
为了解决单机缓存容量过小的问题,一般实践上会采用分布式缓存。也就是将缓存数据分布在多个服务器上,这里就称为 caching server 了,因为这些服务器的功能高度特化。如此一来,缓存横向扩展,避免了单缓存节点过载,提高整体的命中率。
此外,缓存不一致性也是数据缓存需要考虑的问题。缓存不一致性指的是由于缓存和数据库之间的更新不同步,可能导致数据不一致。例如,数据库更新后,缓存仍然存储旧数据。在操作系统中,由于持久化的需求,一般的系统实现会采用写穿透 (Write-through) 或写回 (Write-back) 策略来确保数据的同步。在实践中,缓存系统通常会使用消息队列(如 Kafka)来触发缓存更新。
缓存崩溃也是我们要考虑的一个问题。即如果整个缓存系统突然失效,那么所有请求都会直接访问数据库,可能导致数据库过载,系统的负载突然陡增并引发系统奔溃。
对于缓存崩溃问题,我们同样可以采用分布式的高可用缓存集群,即使一个缓存服务器节点崩溃了,我们也可以确保大部分缓存仍然可用。
在高并发环境下,一个关键数据一时间可能被请求成千上万次。一旦该数据从缓存中过期或被删除,那么多个请求将同时尝试重新计算或获取该数据。假设我们有 1000 个请求,每 10 个请求我们用 1 个线程计算并更新缓存,那么一时间就会有 100 个线程计算更新同一个资源的缓存。这就会导致数据库或后端承受巨大压力、系统资源耗尽,最终导致阻塞崩溃 (congestion collapse)。
这种多个请求同时尝试获取和计算同一数据的情况,被称为缓存踩踏。为了解决缓存踩踏问题,避免后端服务负载过高的问题,我们可以使用互斥锁机制。在缓存失效时,使用分布式锁来确保只有一个请求负责更新缓存。其他请求等待更新完成后从缓存读取,而不是访问数据库。
在某个请求正在更新缓存时,如果有对关键数据的其他请求,那么其他请求就需要进行退避重试 (exponential backoff/backoff retry) ,要求请求方在短暂等待后再次查询缓存。
除了锁机制,我们也可以让后台任务提前刷新缓存,避免缓存过期 (cache refresh)。
上面所述的缓存踩踏发生在某一个热点数据的缓存过期。而我们也会有大量的缓存同时过期的情况,我们称之为缓存雪崩。
在缓存服务器刚刚启动的时候,这时服务器的内存中还没有任何对数据的缓存,如果这时启动服务器,就会导致大量的请求同时涌向数据库,造成数据库负载骤增,甚至导致系统崩溃。这时缓存雪崩的一种情况,但是并不直观(体现不出”雪崩“)。
为了避免缓存服务器刚启动时可能导致的后端负荷骤增,所以我们通常会进行缓存预热 (Cache Prewarming),即在缓存服务器服务请求前就加载好关键数据,减少数据库的压力。而这时,如果设置的缓存 TTL 都是一样的,那么就会让预热好的大量数据在某个时间点同时过期,形成”雪崩“。同样的,若是某个缓存服务器宕机,也会导致大量的缓存数据的失效,形成”雪崩“。
雪崩导致的结果就是所有请求绕过缓存直接访问数据库,造成数据库负载骤增,导致系统崩溃。为了避免雪崩的发生,在设置 TTL 时可以采用随机过期时间(对分配的 TTL 增加随机的偏移量),避免所有缓存同时失效。
最后我们要介绍的缓存陷阱叫缓存穿透,指的是请求根本不存在的的数据,即数据既不在缓存中,也不在数据库中。缓存穿透发生时,每次请求都会直接访问数据库,增加数据库压力。如果有大量这样的请求,就会徒增服务器的负载。攻击者可以有意的发生大量随机的查询请求来影响服务器。
为了避免这种对根本不存在数据的查询,我们可以给已有数据做哈希,将数据的哈希结果存储在一个位数组中。如果请求数据的哈希结果在位数组为 1,那么就说明数据是存在的,否则说明数据不存在。但如果数据量巨大(如用户名,可能是十亿量级的),一个哈希函数可能让多个数据对应同一个位数组(哈希冲突)。
为了避免这种哈希冲突,通常上,我们会使用布隆过滤器 (Bloom Filter),也就是多个哈希函数来计算多个哈希结果并存储在位数组中,能够有效避免哈希冲突。
此外,还可以对不存在的数据存储空值占位符,减少重复查询。